[TOC]
类加载器的主要作用就是加载 Java 类的字节码(
.class文件)到 JVM 中(在内存中生成一个代表该类的Class对象)。 字节码可以是 Java 源程序(.java文件)经过javac编译得来,也可以是通过工具动态生成或者通过网络下载得来。
类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
每个被加载后的 Java 类都有一个引用指向加载它的 ClassLoader。
数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
JVM 中内置了三个重要的 ClassLoader:
BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。ExtensionClassLoader(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。
除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。
每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null的话,那么该类是通过 BootstrapClassLoader 加载的。
public abstract class ClassLoader {
...
// 父加载器
private final ClassLoader parent;
@CallerSensitive
public final ClassLoader getParent() {
//...
}
...
}
为什么 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的呢? 这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。
我们前面也说说了,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。
ClassLoader 类有两个关键的方法:
protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。
根据 Java 虚拟机规范,Class 文件通过 ClassFile 定义,有点类似 C 语言的结构体。
当一个类收到了加载请求时,它不会先自己去尝试加载,而是委派给父类完成。 比如我现在要new一个Person,这个Person是我们自定义的类,如果我们要加载它,就会先委派AppClassLoader,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的Class)时,子类加载器才会自行尝试加载。
这样做的好处就是保证使用不同的类加载器得到的都是同一个结果。保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为
java.lang.Object类的话,那么程序运行的时候,系统就会出现两个不同的Object类。双亲委派模型可以保证加载的是 JRE 里的那个Object类,而不是你写的Object类。这是因为AppClassLoader在加载你的Object类时,会委托给ExtClassLoader去加载,而ExtClassLoader又会委托给BootstrapClassLoader,BootstrapClassLoader发现自己已经加载过了Object类,会直接返回,不会去加载你写的Object类。
执行流程:当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。
loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。findClass() 方法来加载类)。ClassNotFoundException 异常。JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。
自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。
为什么是重写 loadClass() 方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:
类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()方法来加载类)。
重写 loadClass()方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。
我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。
从类被加载到虚拟机内存开始,到释放内存一共有7个步骤:加载,验证,准备,解析,初始化,使用,卸载。
加载:查找并加载类的二进制数据(class文件);方法区:类的类信息。堆:class文件对应的类实例。
加载这一步主要是通过我们后面要讲到的 类加载器完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)。
每个 Java 类都有一个引用指向加载它的
ClassLoader。不过,数组类不是通过ClassLoader创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的。一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的
loadClass()方法)。加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
连接
验证: 确保加载的类符合JVM规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件;
文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候(解析阶段会介绍符号引用和直接引用)。
符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:
准备:为static变量在方法区中分配内存空间,设置变量初始值;
- static变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成。
- 如果static变量是final基本类型以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成。
- 如果static变量是final的,但属于引用类型,那么赋值也会在初始化阶段完成
从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了
public static int value=111,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111,那么准备阶段 value 的值就被赋值为 111.
解析: 虚拟机将常量池内的符号引用替换为直接引用的过程(符号引用比如我现在import java.util.ArrayList这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行)。其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可
直接引用:直接引用是可以直接指向目标的指针,相对偏移量或者是一个能间接定位到目标的句柄。
举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化:JVM对类进行初始化,对静态变量赋予正确值。初始化其实就是执行类构造器方法的()的过程,而且要保证执行前父类的()方法执行完毕。这个方法由编译器收集,顺序执行所有类变量(static修饰的成员变量)显式初始化和静态代码块中语句。此时准备阶段时的那个 static int a 由默认初始化的0变成了显式初始化的3. 由于执行顺序缘故,初始化阶段类变量如果在静态代码块中又进行了更改,会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值。
- 当遇到
new、getstatic、putstatic,invokestatic这 4 条字节码指令时,比如 new一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
- 当 jvm 执行
new指令时会初始化类。即当程序创建一个类的实例对象。- 当 jvm 执行
getstatic指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。- 当 jvm 执行
putstatic指令时会初始化类。即程序给类的静态变量赋值。- 当 jvm 执行
invokestatic指令时会初始化类。即new 会导致初始化。- 使用
java.lang.reflect包的方法对类进行反射调用时如Class.forName("..."),newInstance()等等。如果类没初始化,需要触发其初始化。- 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
- 当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main方法的那个类),虚拟机会先初始化这个类。MethodHandle和VarHandle可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,
就必须先使用findStaticVarHandle来初始化要调用的类。- 「补充,来自issue745open in new window」 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
以下情况不会初始化
- 访问类的 static final 静态常量(基本类型和字符串)
- 类对象.class 不会触发初始化
- 创建该类对象的数组
- 类加载器的.loadClass方法
- Class.forNamed的参数2为false时
验证类是否被初始化,可以看改类的静态代码块是否被执行
卸载:GC将无用对象从内存中卸除。卸载类需要满足 3 个要求:
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
线程私有的:
线程共享的:
java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是本地方法,这个计数器则应该为空。OutOfMemoryError情况的区域。每个线程运行所需要的内存,称为虚拟机栈。
除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
java虚拟机栈是由一个个栈帧组成,对应着每次方法调用时所占的内存。每个栈帧都有:局部变量表,操作数栈,动态链接,方法返回地址等信息。
Java虚拟机的各种基本数据类型(boolean,byte,char,short,int float,long,double),对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者与此对象相关的位置)、returnAddress类型(指向了一条字节码指令的地址,初衷是用来实现Java语言中的finally语句块)。每个线程只能有一次活动栈帧,对应着当前正在执行的那个方法。
两类异常:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果java虚拟机栈容量可以动态扩展(HotSpot不能扩展),当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
当每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,每个方法被调用直至执行完毕的过程,就对应着一个栈帧再虚拟机从入栈到出栈的过程。
基本数据类型包括 boolean, byte,char,short,int,float,double,long.
对象引用:reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能使指向一个代表对象的句柄或者其他与此对象相关的位置。
returnAddress类型:指向了一条字节码指令的地址。垃圾回收不涉及栈内存,栈内存在每次方法调用完毕出栈之后就自动被回收了。
线程安全问题
- 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的。
- 如果局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全问题。
栈内存溢出
- 栈帧过多造成栈内存溢出
- 单个栈帧过大造成栈内存溢出(不太容易出现)
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。
- 堆内存溢出问题
- java.lang.OutofMemoryError :java heap space. 堆内存溢出
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
可以选择不实现垃圾回收,回收目标主要时针对常量池的回收和对类型的卸载。
永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。变换的原因:
java.lang.OutOfMemoryError: MetaSpaceMaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。是方法区的一部分
常量池:就是一张表(编译器生成的字面量和符号引用),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。
运行时常量池:常量池是 *.class 文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
Class文件中除了由类的版本,字段,方法,接口等描述信息外,还有一项时常量池表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量(字符串,整数,布尔类型等)等信息。
二进制字节码包含(类的基本信息,常量池,类方法定义,包含了虚拟机的指令)
首先看看常量池是什么,编译如下代码:
public class Test {
public static void main(String[] args) {
System.out.println("Hello World!");
}
然后使用 javap -v Test.class 命令反编译查看结果。
每条指令都会对应常量池表中一个地址,常量池表中的地址可能对应着一个类名、方法名、参数类型等信息
src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。方法区主要回收的是无用的类。类需要同时满足下面 3 个条件才能算是 “无用的类” :
ClassLoader 已经被回收。java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
假如在字符串常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池了。
不使用直接内存
使用直接内存
直接内存是操作系统和Java代码都可以访问的一块区域,无需将代码从系统内存复制到Java堆内存,从而提高了效率.
用来执行方法区中的字节码指令
public class TestFrame{//1
public static void main(String []args){ //2
method1(10);//3
}
private static void method1(int x){//4
int y = x + 1;//5
Object m = method2();//6
System.out.println(m);//7
}
private static Object method2(){//8
Object n = new Object();//9
return n;//10
}
}
第九行:在堆中新建一个对象,并将对象的引用n指向对象。
第10行:返回n,并将m指向object对象。
类加载检查(先判断有没有加载过)
虚拟机遇到一个new指令,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过,解析和初始化过,如果没有,那必须先执行响应的类加载过程。
分配内存(没有加载过就为对象在堆中分配内存)
在类加载检查通过后,接下来虚拟机将会为新生对象分配内存。对象所需的内存大小在类加载完成后便可确认,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。分类方式有指针碰撞和空闲列表方式,选择哪种分配方式是由java堆是否规整决定的,而java堆是否规整又有采用的垃圾收集器是否带有压缩整理功能决定。
HotSpot虚拟机内存分配的两种方式
- 指针碰撞
- 适用场合:堆内存规整(即没有内存碎片);
- 用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界值指针,只需要向着没有用过的内存方向将该指针移动对象内存大小位置即可。
- GC收集器:Serial,ParNew
- 空闲列表
适合用于堆内存不规整的情况;
虚拟机会维护一个列表,该列表会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来分配给对象实例,最后更新列表记录.
GC收集器:CMS
HotSpot虚拟机内存分配的并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- 线程本地分配缓存区(TLAB): 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
初始化零值
内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象实例字段在java代码中可以不赋初始值就可以使用,程序能访问到这些字段的数据类型所对应的零值。
设置对象头
初始化零值后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如果才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息。这些信息存放在对象头中。另外根据虚拟机当前运行状态的不同,如是否用偏向锁等,对象头会有不同的设置方式。
执行方法
new指令之后会接着执行方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才完全被构造出来。
在上面工作都完成之后,从虚拟机的视角,一个对象已经产生了,但是从java程序视角来看,构造函数,即class文件中的方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。
在hotSpot虚拟机中,对象在内存中的布局可以分为3块区域:对象头,实例数据和对齐填充。
java程序通过栈上的reference数据来操作堆上的具体对象,对象的访问方式由虚拟机实现而定,目前主流的访问方式由使用句柄和直接指针两种。
这两种对象访问方式各有优势,使用句柄来访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问的最大好处就是速度快,它节省了一次指针定位的时间开销。
句柄: 如果使用句柄的化,java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据域类型数据各自的具体地址信息。
直接指针:如果使用直接指针访问,那么java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象的地址。
方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
引用本身也是一种对象
软引用:List<SoftReference<byte[]>> list=new ArrayLIst<>();
SoftReference<byte[]> ref = new SoftReference<>(new byte[1024])
list.add(ref);
在这里list 强引用SoftReference对象,然后通过SoftReference间接引用byte数组。
强引用:对于强引用,垃圾回收器绝对不会回收它(即使内存不足)。只有GC Root都不引用该对象时,才会回收强引用对象。
软引用:当GC Root指向软引用对象时,在内存不足时,会回收软引用所引用的对象。但是软引用本身不会被清理。如果想要清理软引用,需要使用引用队列。大概思路为:查看引用队列中有无软引用,如果有,则将该软引用从存放它的集合中移除(这里为一个list集合。图片缓存框架中,“内存缓存”中的图片是以这种引用保存,使得 JVM 在发生 OOM 之前,可以回收这部分缓存。
//软引用
public class Demo1 {
public static void main(String[] args) {
final int _4M = 4*1024*1024;
//使用软引用对象 list强引用SoftReference,而SoftReference和byte数组则是软引用
List<SoftReference<byte[]>> list = new ArrayList<>();
SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M]);
}
}
//清理软引用需要使用引用队列
public class Demo1 {
public static void main(String[] args) {
final int _4M = 4*1024*1024;
//使用引用队列,用于移除引用为空的软引用对象
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
//使用软引用对象 list和SoftReference是强引用,而SoftReference和byte数组则是软引用
List<SoftReference<byte[]>> list = new ArrayList<>();
SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M]);
//遍历引用队列,如果有元素,则移除
Reference<? extends byte[]> poll = queue.poll();
while(poll != null) {
//引用队列不为空,则从集合中移除该元素
list.remove(poll);
//移动到引用队列中的下一个元素
poll = queue.poll();
}
}
}
弱引用:只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象。如上图如果B对象不再引用A3对象,则A3对象会被回收。弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference。
虚引用:如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。当虚引用对象所引用的对象被回收以后,虚引用对象就会被放入引用队列中。
引用队列
标记清除算法: 在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
标记复制算法:将内存分为大小相同的两块,每次使用其中的一块,当这一块的内存使用完后,就将还存活的对象复制到另外一块去,然后把使用的空间一次清理掉。
标记整理算法: 让存活的对象向一端移动,直接清理掉端边界以外的内存。
分代收集算法
Serial(串行)收集器是一个单线程收集器了。它只会使用一条垃圾收集线程去完成垃圾收集工作,在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:
Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器。
-XX:+UseParallelGC
使用 Parallel 收集器+ 老年代串行
-XX:+UseParallelOldGC
使用 Parallel 收集器+ 老年代并行
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
ParNew 收集器其实就是 Serial 收集器的多线程版本,也就是GC线程并发,应用程序暂停。 除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
Garbage First
JDK 9以后默认使用,而且替代了CMS 收集器
回收阶段
初始标记: 在 Young GC 时会对 GC Root 进行初始标记,仅仅发生在新生代回收阶段, 会STW;
并发标记:在老年代占用堆内存的比例达到阈值(45%)时,对进行并发标记,从根对象出发,标记其他对象(不会STW),阈值可以根据用户来进行设定。
最终标记(Remark):会STW. 在并发标记过程中,有可能会漏掉一些对象,需要重新标记。
拷贝存活:会STW;并不是所有的老年代都会被拷贝。
问:为什么有的老年代被拷贝了,有的没拷贝?
因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存).
1. 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
2. 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制、
3. 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold 用来定义年龄的阈值
4. 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
5. 空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
1. 调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存.
2. 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
3. 空间分配担保失败
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
4. JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
5. Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
java的常量池有三种,即字符串常量池,class常量池,运行时常量池。
class常量池:我们写的每一个java类被编译之后都会生成一个对应的class文件。class文件中除了有类的版本,字段方法,接口等描述信息外,还有一项信息时常量池,用于存放编译期生成的各种字面量和符号引用。每个类都有一个class常量池。
什么时字面量和符号引用?
字面量比较接近于java语言层面的常量概念,如文本字符串,声明为final的常量值和8种基本类型的变量,而符号引用则属于编译原理方面的概念,包括三类变量:类和接口的全限定名。字段的名称和描述符。方法的名称和描述符
字符串常量池
运行时常量池
string类对象创建的两种方式:
String str1='abcd';//先检查字符串常量池中有没有‘abcd’,如果没有,则创建一个,然后str1指向字符串常量池中的对象,如果有,直接将str1指向‘abcd’
String str2=new String("abcd");//堆中创建一个新对象
String str3=new String("abcd");//堆中创建一个新对象
System.out.println(str1==str2);//false
System.out.println(str1==str2);//false
这两种不同的创建方法是有差别的。
String类的常量池比较特殊,它的主要使用方法有两种:
String.intern()是一个Native方法,它的作用是:如果运行时常量池中已经包含了一个等于String对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7之前(不包含1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7以及之后的处理方式是在常量池中记录此字符串的引用,并返回该引用。 String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,因为一个是堆内存中的 String 对象一个是常量池中的 String 对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的 String 对象
字符串拼接
String str1="str";
String str2="ing";
String str3="str"+"ing";//常量池中的对象
String str4=str1+str2;//在堆上创建的新的对象
String str5="string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
将创建一个或者两个字符串。
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
–Xms和-Xmx-Xms<heap size>[unit]
-Xmx<heap size>[unit]
举个栗子 🌰,如果我们要为 JVM 分配最小 2 GB 和最大 5 GB 的堆内存大小,我们的参数应该这样来写:
-Xms2G -Xmx5G
显式新生代内存(Young Generation)
通过-XX:NewSize和-XX:MaxNewSize指定
-XX:NewSize=<young size>[unit]
-XX:MaxNewSize=<young size>[unit]
举个栗子 🌰,如果我们要为 新生代分配 最小 256m 的内存,最大 1024m 的内存我们的参数应该这样来写:
-XX:NewSize=256m
-XX:MaxNewSize=1024m
通过-Xmn<young size>[unit]指定
举个栗子 🌰,如果我们要为 新生代分配 256m 的内存(NewSize 与 MaxNewSize 设为一致),我们的参数应该这样来写:
-Xmn256m
通过 -XX:NewRatio=<int> 来设置老年代与新生代内存的比值。
比如下面的参数就是设置老年代与新生代内存的比值为 1。也就是说老年代和新生代所占比值为 1:1,新生代占整个堆栈的 1/2。
-XX:NewRatio=1
显式指定永久代/元空间的大小 :Metaspace 的初始容量并不是 -XX:MetaspaceSize 设置,无论 -XX:MetaspaceSize 配置什么值,对于 64 位 JVM 来说,Metaspace 的初始容量都是 21807104(约 20.8m)。
JDK 1.8 之前
-XX:PermSize=N #方法区 (永久代) 初始大小
-XX:MaxPermSize=N #方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
JDK 1.8
-XX:MetaspaceSize=N #设置 Metaspace 的初始大小(是一个常见的误区,后面会解释)
-XX:MaxMetaspaceSize=N #设置 Metaspace 的最大大小
垃圾回收器JVM 具有四种类型的 GC 实现:
串行垃圾收集器
并行垃圾收集器
CMS 垃圾收集器
G1 垃圾收集器
可以使用以下参数声明这些实现:
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseConcMarkSweepGC
-XX:+UseG1GC
# 必选
# 打印基本 GC 信息
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
# 打印对象分布
-XX:+PrintTenuringDistribution
# 打印堆数据
-XX:+PrintHeapAtGC
# 打印Reference处理信息
# 强引用/弱引用/软引用/虚引用/finalize 相关的方法
-XX:+PrintReferenceGC
# 打印STW时间
-XX:+PrintGCApplicationStoppedTime
# 可选
# 打印safepoint信息,进入 STW 阶段之前,需要要找到一个合适的 safepoint
-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1
# GC日志输出的文件路径
-Xloggc:/path/to/gc-%t.log
# 开启日志文件分割
-XX:+UseGCLogFileRotation
# 最多分割几个文件,超过之后从头文件开始写
-XX:NumberOfGCLogFiles=14
# 每个文件上限大小,超过就触发分割
-XX:GCLogFileSize=50M
对于大型应用程序来说,面对内存不足错误是非常常见的,这反过来会导致应用程序崩溃。这是一个非常关键的场景,很难通过复制来解决这个问题。
这就是为什么 JVM 提供了一些参数,这些参数将堆内存转储到一个物理文件中,以后可以用来查找泄漏:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=./java_pid<pid>.hprof
-XX:OnOutOfMemoryError="< cmd args >;< cmd args >"
-XX:+UseGCOverheadLimit
这里有几点需要注意:
<pid> 标记,则当前进程的进程 id 将附加到文件名中,并使用.hprof格式cmd args 空间中使用适当的命令。例如,如果我们想在内存不足时重启服务器,我们可以设置参数: -XX:OnOutOfMemoryError="shutdown -r" 。-server : 启用“ Server Hotspot VM”; 此参数默认用于 64 位 JVM
-XX:+UseStringDeduplication : Java 8u20 引入了这个 JVM 参数,通过创建太多相同 String 的实例来减少不必要的内存使用; 这通过将重复 String 值减少为单个全局 char [] 数组来优化堆内存。
-XX:+UseLWPSynchronization: 设置基于 LWP (轻量级进程)的同步策略,而不是基于线程的同步。
-XX:LargePageSizeInBytes: 设置用于 Java 堆的较大页面大小; 它采用 GB/MB/KB 的参数; 页面大小越大,我们可以更好地利用虚拟内存硬件资源; 然而,这可能会导致 PermGen 的空间大小更大,这反过来又会迫使 Java 堆空间的大小减小。
-XX:MaxHeapFreeRatio : 设置 GC 后, 堆空闲的最大百分比,以避免收缩。
-XX:SurvivorRatio : eden/survivor 空间的比例, 例如-XX:SurvivorRatio=6 设置每个 survivor 和 eden 之间的比例为 1:6。
-XX:+UseLargePages : 如果系统支持,则使用大页面内存; 请注意,如果使用这个 JVM 参数,OpenJDK 7 可能会崩溃。
-XX:+UseStringCache : 启用 String 池中可用的常用分配字符串的缓存。
-XX:+UseCompressedStrings : 对 String 对象使用 byte [] 类型,该类型可以用纯 ASCII 格式表示。
-XX:+OptimizeStringConcat : 它尽可能优化字符串串联操作。
这些命令在 JDK 安装目录下的 bin 目录下:
jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据;jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息;jmap (Memory Map for Java) : 生成堆转储快照;jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。JDK9 移除了 jhat;jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。jconsole命令启动或者在 JDK 目录下的 bin 目录找到jconsole.exe然后双击启动。